不知道你有沒有這種感覺:前幾天我們寫完 FSM(有限狀態機)之後,雖然可以手動按按鈕幫 Agent 加減好感度,但總覺得哪裡怪怪的?
「真正的聊天,誰會在旁邊準備一顆加分按鈕啊!」
如果莉莉 Agent 真是一個活生生的人,她應該要能從我們的字裡行間,自己感受到被尊重、被關心,或是被冒犯。而且更重要的是,她不能是個金魚腦!總不能網頁按個 F5 重新整理,她就把我們之前建立的羈絆跟好感度全都忘光了吧?
所以今天,我們要來做兩件讓角色「活過來」的核心工作:
FSM 集合裡,就算重新整理網頁,情感狀態依然緊緊鎖住。FSM 集合)打開 db.py,我們寫入這兩個讀寫函式:
# ---- FSM: 角色好感度 ----
# 好感度存在獨立的 FSM 集合,一個角色一份文件(不是一個使用者一份),
# 所以目前是「全站共用一個莉莉」;
# 等要做成每人各自的好感度時,這裡的 document key 要改成帶 user_id(未來式)
DEFAULT_AFFECTION = 20
# 從 Firestore 的 FSM 集合讀取角色的好感度數值
def get_fsm_affection(character_id: str = "lily", default_val: int = DEFAULT_AFFECTION) -> int:
doc_ref = db.collection("FSM").document(character_id)
doc = doc_ref.get()
if doc.exists:
data = doc.to_dict() or {}
return data.get("affection", default_val)
else:
# 若資料不存在,建立預設文件,下次讀取就有值了
save_fsm_affection(character_id, default_val)
return default_val
# 將好感度數值寫入/更新至 Firestore 的 FSM 集合
def save_fsm_affection(character_id: str, affection: int):
doc_ref = db.collection("FSM").document(character_id)
# merge=True:只覆寫這三個欄位,不會把文件裡其他欄位清掉
doc_ref.set({
"character_id": character_id,
"affection": affection,
"updated_at": firestore.SERVER_TIMESTAMP
}, merge=True)
fsm.py)現在,我們的 FSM 類別不再是孤島了。它一出生(初始化)就會去問 Firebase:「Hey,我們上次聊到哪了?」
Python
import db
class FSM:
def __init__(self, character_id: str = "lily", default_affection: int = db.DEFAULT_AFFECTION):
self.character_id = character_id
# 一開始就自動去 Firebase 的 FSM 集合討記憶!
self.affection = db.get_fsm_affection(self.character_id, default_val=default_affection)
def update_affection(self, delta: int):
"""更新好感度分數,並限制在安全邊界內"""
self.affection = min(max(0, self.affection + delta), 100)
# 寫回雲端,讓記憶延續
db.save_fsm_affection(self.character_id, self.affection)
return self.affection
小提醒:前幾天測試後發現,原本 30 / 70 的門檻太容易讓角色在短時間內跨狀態,因此這次重新調整為 15 / 75,讓
HOSTILE與IN_LOVE都需要更明顯的互動累積才能進入或離開。
evaluator.py)這是我覺得比較有趣的部分!我用一個輕巧的 Gemini API 呼叫,擔任莉莉的「潛意識情商模組」。
Python
from google import genai
# 評分準則獨立成常數,讓之後合併呼叫時可以直接引用,
# 不必把同一份規則抄兩遍(抄兩遍就一定會有天只改到其中一邊)
EMOTION_CRITERIA = """
莉莉的性格特點:極度重視家人與店裡的夥伴、熱愛做料理與甜點、討厭無禮自大的人、
對真誠誇獎她廚藝的人毫無抵抗力。
評分的標準如下:
- 真誠誇獎她的料理/甜點、表達對她家人的尊重、展現貼心與照顧:+5 到 +11 分
- 一般日常問候、無特別偏向的對話、無心的玩笑或誤會:0 到 -2 分
- 態度輕浮、開不適當的玩笑、批評她的家人或料理:-5 到 -9 分(除非極度惡劣否則嚴禁低於 -11 分)
"""
# client 由呼叫端傳入;設定只改一處,測試時也能塞假 client 進來,不必真的打 API
def evaluate_emotion(client: genai.Client, user_message: str) -> int:
"""讓 Gemini 充當情商裁判,感受使用者話語中的溫度,回傳好感度波動"""
prompt = f"""
你是一個極度了解莉莉性格的情緒裁判。
{EMOTION_CRITERIA}
請仔細閱讀使用者剛才說的話,評估這句話對莉莉心理造成的影響:
使用者說:「{user_message}」
請務必只回傳一個整數數字(例如:10 或 -5),不要包含任何多餘文字。
"""
try:
response = client.models.generate_content(
model="gemini-3.1-flash-lite",
contents=prompt
)
# response.text 被 safety filter 擋掉時會是 None,用 or "" 墊底
score_text = (response.text or "").strip()
return int(score_text)
except Exception as e:
# 萬一情商模組偶爾走神,預設不影響心情
# 靜默 return 0 的話畫面上只會看到「好感度都不動」,完全無從查起
print(f"[evaluator] 情緒評分失敗,本輪以 0 計:{type(e).__name__}: {e}")
return 0
app.py 串起溫暖的對話閉環Python
if user_input := st.chat_input("跟你的角色說點什麼吧..."):
with st.chat_message("user", avatar=USER_AVATAR):
st.write(user_input)
# 意圖判斷也放進 try 裡:classify_intent 內部同樣是一次 API 呼叫,
# 一輪對話等於打兩次 API,免費層的 RPM 上限真實砍半,
# 放在 try 外面的話 router 一撞 429 就是整頁 traceback,接不到下面的 st.error
try:
intent = router.classify_intent(client, user_input)
if intent == "CHAT":
# 背景靜悄悄地感知情緒,換算成好感度增減
delta = evaluator.evaluate_emotion(client, user_input)
# 只在 CHAT 路線評分:TASK 是叫她算數學、寫程式,
# 那種話沒有情緒溫度可言,拿去評分只是白花一次 API
st.session_state.fsm.update_affection(delta)
# 取得當前 FSM 狀態對應的動態 System Instruction
# (好感度更新後才取,這一輪就會反映最新狀態)
current_instruction = st.session_state.fsm.get_system_instruction()
response = st.session_state.chat.send_message(
user_input,
config=types.GenerateContentConfig(system_instruction=current_instruction)
)
ai_reply = response.text
else:
response = st.session_state.chat.send_message(
user_input,
config=types.GenerateContentConfig(
system_instruction="請以極度精準、高效率的方式完成使用者的任務,不要有任何傲嬌廢話或角色扮演。"
)
)
ai_reply = f"(哼!這種正經事就交給我吧)\n\n{response.text}"
# 用旗標而不是 st.stop():st.stop() 會中斷整個 script,
# 連下面的側邊欄儀表板都不會被畫出來,出錯時等於整個側邊欄消失
replied = True
except Exception as e:
st.error(f"角色沒有回應,API 出錯了:{e}")
st.info("這句話沒有被記錄,重新整理後會消失,可以直接再說一次。")
replied = False
if replied:
with st.chat_message("assistant", avatar=AI_AVATAR):
st.write(ai_reply)
st.session_state.messages.append({"role": "user", "content": user_input})
save_message(st.session_state.user_id, "user", user_input)
st.session_state.messages.append({"role": "assistant", "content": ai_reply})
save_message(st.session_state.user_id, "assistant", ai_reply)
# 側邊欄顯示好感度
# 放在檔案最後才畫:Streamlit 由上往下執行,
# 若放在開頭,讀到的會是這一輪更新「之前」的好感度,畫面永遠慢一拍。
with st.sidebar:
st.header("💖 心情儀表板")
st.metric("當前好感度", f"{st.session_state.fsm.affection} / 100")
st.write(f"目前狀態:**{st.session_state.fsm.get_state()}**")
好感度:20 ⇒ 15(TSUNDERE ⇒ HOSTILE)
使用者:「你想要得心臟病嗎?」
莉莉:「哈?一開始就咒人生病,你也太沒禮貌了吧。你腦子要是壞了就快點去看醫生,少在那邊噁心我了。」
好感度:15 ⇒ 19(HOSTILE ⇒ TSUNDERE)
使用者:「我最近找到一家很好吃的甜點店,有機會約你一起去嗎?」
莉莉:「哼,甜點店?別以為用這種東西就能打發我!……不過,看在你這麼有誠意的份上,我就勉為其難陪你去一次好了。先說好喔!這才不是什麼約會,我只是想去幫你鑑定一下那家店的水準而已,笨蛋!」
好感度:19 ⇒ 18(TSUNDERE ⇒ TSUNDERE)

現象描述
測試對話時突發 429 RESOURCE_EXHAUSTED 錯誤,提示 Quota exceeded ... limit: 20,導致 Agent 系統全面無法回應。
原因分析
gemini-3.6-flash 模型,其免費層(Free Tier)設定了嚴苛的 RPD 20(每日最多 20 次請求) 限制。核心解決方案:模型分流與輕量化(Model Offloading)
為了兼顧回應速度、額度消耗與角色扮演的深度,採用輕量模型處理輔助任務,主力模型處理角色對話的分流架構:
• 輔助任務(Router / Evaluator)⇒ gemini-3.1-flash-lite
意圖分類與情緒評估只需要精準輸出標籤或分數,不需要複雜的角色扮演與文采。改用 flash-lite 能極大地節省 API 配額並降低回應延遲(Latency)。